本章的篇幅比較長,但又不想捨棄任何精彩的部分,謝謝大家陪我讀到這,再努力一下!
昨天,我們成功測試鴨子類型程式碼;今天,當然不能放過繼承程式碼囉(笑
里氏代替原則聲明子類型應該都要能夠代替它們的父類型。要證明層次結構裡的所有物件都符合里氏代替原則,最簡單的方法是撰寫一項共用的測試,並且將這項測試包含在所有物件裡。
以第6章的Bicycle
類別為例,RoadBike
爲Bicycle
的子類別 :
class Bicycle
attr_reader :size, :chain, :tire_size
def initialize(args ={})
@size = args[:size]
@chain = args[:chain] || default_chain
@tire_size = args[:tire_size] || default_tire_size
post_initialize(args)
end
def spares
{tire_size: tire_size,
chain: chain}.merge(local_spares)
end
def default_tire_size
raise NotInclementedError
end
# 子類別可以覆蓋
def post_initialize(args)
nil
end
def local_spares
{)
end
def default_chain
'10-speed'
end
end
class RoadBike < Bicycle
attr_reader :tape_color
def post_initialize(args)
@tape_color = args[:tape_color]
end
def local_spares
{tape_color: tape_color}
end
def default_tire_size
'23'
end
end
我們會預設一個情境是通過BicyclelnterfaceTest
的任何物件,都可以被認為像一個Bicycle
module BicyclelnterfaceTest
def test_responds_to_default_tire_size
assert_respond_to(@object, :default_tire_size)
end
def test_responds_to_default_chain
assert_respond_to(@object, :default_chain)
end
def test_responds_to_chain
assert_respond_to(@object, :chain)
end
def test_responds_to_size
assert_respond_to(@object, :size)
end
def test_responds_to_tire_size
assert_respond_to(@object, :tire_size)
end
def test_responds_to_spares
assert_respond_to(@object, :spares)
end
end
class BicycleTest < MiniTest::Unit::TestCase
include BicyclelnterfaceTest
def setup
@bike = @object = Bicycle.new({tire_size: 0})
end
end
class RoadBikeTest < MiniTest::Unit::TestCase
include BicyclelnterfaceTest
def setup
@bike = @object = RoadBike.new
end
end
BicycleTest
PASS test_responds_to_defau1t_chain
PASS test_responds_to_size
PASS test_responds_to_tire_size
PASS test_responds_to_chain
PASS test_responds_to_spares
PASS test_responds_to_default_tire_size
RoadBikeTest
PASS test_responds_to_chain
PASS test_responds_to_tire_size
PASS test_responds_to_default_chain
PASS test_responds_to_spares
PASS test_responds_to_default_tire_size
PASS test_responds_to_size
確認子類別行為
這項測試彙集了對Bicycle
子類別的要求。任何子類別都可以任意地繼承post_initialize
和local_spares
。子類別唯一必須實作的方法是default_tire_size
。父類別的default_tire_size
實作會引發錯誤,而這項測試會出現失敗,除非子類別實作了自己的特殊化版本。
module BicycleSubclassTest
def test_responds_to_post_initialize
assert_respond_to(@object, :post_initialize)
end
def test_responds_to_local_spares
assert_respond_to(@object, :local_spares)
end
def test_responds_to_default_tire_size
assert_respond_to(@object, :default_tire_size)
end
end
class RoadBikeTest < MiniTest::Unit::TestCase
include BicyclelnterfaceTest
include BicycleSubclassTest
def setup
@bike = @object = RoadBike.new
end
end
RoadBikeTest
PASS test_responds_to_default_tire_size
PASS test_responds_to_spares
PASS test_responds_to_chain
PASS test_responds_to_post_initialize
PASS test_responds_to_local_spares
PASS test_responds_to_size
PASS test_responds_to_tire_size
PASS test_responds_to_default_chain
確認父類別實作
如果子類別沒有實作default_tire_size
,那麼 Bicycle
類別應該會引發錯誤。
class BicycleTest < MiniTest::Unit::TestCase
include BicyclelnterfaceTest
def setup
@bike = @object = Bicycle.new({tire_size: 0})
end
def test_forces_subclasses_to_implement_default_tire_size
assert_raises(NotInplementedError) {@bike.default_tire_size}
end
end
BicycleTest
需要一個用於進行測試的物件,而最明顯的候選項就是Bicycle
實例。就目前而言,只要提供 tire_size
參數就能夠順利運作。現在執行BicycleTest
,其輸出訊息看起來就更像是一個抽象父類別。
BicycleTest
PASS test_responds_to_default_tire_size
PASS test_responds_to_size
PASS test_responds_to_default_chain
PASS test_responds_to_tire_size
PASS test_responds_to_chain
PASS test_responds_to_spares
PASS test_forces_subclasses_to_implement_default_tire_size
測試具體子類別的行為
測試這些特殊化時不將父類別的知識嵌入到測試裡很重要。例如,RoadBike
實作了local_spares
,並且會回應spares
。而RoadBikeTest
則應該確保local_spares
可以運作,同時還要刻意忽視spares
方法的存在。
class RoadBikeTest < MiniTest::Unit::Testcase
include BicyclelnterfaceTest
include BicycleSubclassTest
def setup
@bike = @object = RoadBike.new(tape_color: 'red')
end
def test_puts_tape_color_in_local_spares
assert_equal 'red', @bike.local_spares[:tape_color]
end
end
執行RoadBikeTest
即可表明:它符合共同職責,並且也提供了自己的特殊化:
RoadBikeTest
PASS test_responds_to_default_chain
PASS test_responds_to_default_tire_size
PASS test_puts_tape_color_in_local_spares
PASS test_responds_to_spares
PASS test_responds_to_size
PASS test_responds_to_local_spares
PASS test_responds_to_post_initialize
PASS test_responds_to_tire_size
PASS test_responds_to_chain
測試抽象父類別的行為Bicycle
是一 個抽象父類別。建立Bicycle
實例不僅困難,並且這個實例可能缺乏執行測試所需的全部行為。由於Bicycle
使用「範本方法」來取得具體的特殊化,將通常是由子類別所提供的行為截短,並建立一個僅用於此測試的新子類別, 輕易地製造出可供測試的Bicycle
實例。
class StubbedBike < Bicycle
def default_tire_size
0
end
def local_spares
{saddle: 'painful'}
end
end
class BicycleTest < MiniTest::Unit::Testcase
include BicycleInterfaceTest
def setup
@bike = @object = Bicycle.new({tire_size: 0})
@stubbed_bike = StubbedBike.new
end
def test_forces_subclasses_to_implement_default_tire_size
assert_raises (NotlirplementedError) {
@bike.default_tire_size)
end
def test_includes_local_spares_in_spares
assert_equal @stubbed_bike.spares,
{tire_size: 0,
chain: '10-speed',
saddle: 'painful'}
end
end
藉由建立子類別來提供截短功能的概念,現在執行BicycleTest
即可證明它在spares
的列表裡包含了子類別所提供的內容:
BicycleTest
PASS test_responds_to_spares
PASS test_responds_to_tire_size
PASS test_responds_to_default_chain
PASS test_responds_to_defau1t_tire_size
PASS test_forces_subclasses_to_implement_default_tire_size
PASS test_responds_to_chain
PASS test_includes_local_spares_in_spares
PASS test_responds_to_size
如果擔心StubbedBike
會變得過時,使得BicycleTest
在應該失敗時卻通過,使用BicycleSubclassTest
來確保StubbedBike
的正確性能夠延續。
#證明測試替身遵從了
#這項測試所期望的介面。
class StubbedBikeTest < MiniTest::Uiiit::TestCase
include BicycleSubclassTest
def setup
@object = StubbedBike.new
end
end
StubbedBikeTest
PASS test_responds_to_default_tire_size
PASS test_responds_to_local_spares
PASS test_responds_to_post_initialize
為整體介面撰寫一個可共用的測試,並為子類別的職責撰寫其他測試。儘可能將職責隔離起來。特別要注意子類別特殊化的測試,應防止父類別知識洩漏到子類別的測試裡。
設計良好的應用程式具有高度抽象的特點,如果沒有測試,這些應用程式既無法被理解,也無法安全地進行修改。最佳的測試是保持與底層程式碼之間的鬆散耦合,並且所有事物都只在適當的地方測試一次。
參考資料: